feat: replace custom session auth with FerrisKey OIDC (Auth.js v5)#11
Merged
Conversation
Identity is now owned by FerrisKey (Keycloak-alternative IAM) over OIDC via
Auth.js v5. Scope is authentication only: workspace tenancy + owner/member role
stay in the local memberships table, re-derived per request (Invariant 8).
- core/db: add app_user.external_subject + linkOrCreateUserBySubject (memory +
Drizzle), migration 0006. Match by IdP sub, else verified-email backfill, else
JIT-create. Unverified-email collision is rejected (IdentityConflictError) to
prevent account takeover.
- web: apps/web/auth.ts custom OIDC provider (PKCE, jwt/session callbacks pin the
local user id) + [...nextauth] route. resolveWorkspace keyed by {userId};
resolveActiveWorkspace rides on auth(). Remove hand-rolled session-crypto/secret.
- ui: "Continue with FerrisKey" sign-in; Auth.js sign-out + hint-cookie clear;
switchWorkspace fails closed before writing the hint.
- infra: FerrisKey API/console/Postgres in docker-compose (profile auth, pg 5434);
just ferriskey-up/down/bootstrap; scripts/ferriskey-bootstrap.ts.
- hardening: baseline security headers, Auth.js error->sign-in routing.
- env/docs: AUTH_SECRET + FERRISKEY_* ; TOOLS.md, DESIGN.md, ROADMAP updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| token, | ||
| json: { temporary: false, credential_type: 'password', value: DEMO_PASSWORD }, | ||
| }); | ||
| console.log(`✓ demo user ${DEMO_EMAIL} ready (password: ${DEMO_PASSWORD})`); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the hand-rolled email "dev sign-in" + HMAC session cookie with real OIDC authentication against FerrisKey (a Rust, Keycloak-API-compatible IAM), via Auth.js v5.
Scope is authentication only (a deliberate decision): FerrisKey owns who you are; tenancy (workspace + owner/member role) stays in the local
membershipstable and is re-derived per request — preserving Product Invariant 8 and the append-only membership moat. The bridge is the localAppUser: each sign-in maps the verified OIDC identity to exactly one local user and pins that internal id on the session.Route protection stays in the server layer (
authed/layout.tsx→resolveActiveWorkspace), not middleware — which also sidesteps Next 16'smiddleware.ts→proxy.tsedge-runtime gotcha.What changed
Identity bridge
app_user.external_subject(nullable, unique) + Drizzle migration0006.linkOrCreateUserBySubjectonFlankStore(Memory + Drizzle): match by immutable IdPsub→ else verified-email backfill of a pre-provisioned row → else JIT-create. A membership-less user fails closed to the "no workspace" screen.Auth.js wiring (
apps/web)auth.ts— lazy-config custom OIDC provider against the realm discovery URL (PKCE;jwt/sessioncallbacks pin the local user id; profile validated at the boundary).app/api/auth/[...nextauth]/route.ts— handlers.lib/auth/resolver.tsre-keyed to{ userId };session.tsrides onauth(). Deleted hand-rolledsession-crypto.ts/secret.ts(+ tests).UI — "Continue with FerrisKey" sign-in; Auth.js sign-out + workspace-hint clear;
switchWorkspacefails closed before writing state.Local infra — FerrisKey API/console/Postgres added to
docker-compose.yml(profileauth; its PG on host5434to avoid the app's5432and the integration5433);just ferriskey-{up,down,bootstrap};scripts/ferriskey-bootstrap.ts(realm + confidential client + redirect URIs + demo user — grounded in FerrisKey v0.6.1's admin API/validators).Env/docs —
AUTH_SECRET+FERRISKEY_*in.env.example; TOOLS.md, DESIGN.md, ROADMAP updated.Security
Ran a security review of the auth surface. The one genuine account-takeover path — email-backfill linking with no
email_verifiedcheck — is closed:emailVerifiedis threaded throughExternalIdentity, and an unverified email colliding with an existing account is rejected (IdentityConflictError), with a contract test on both stores. Also added: Auth.js error→sign-in routing, a session guard onswitchWorkspace, baseline security headers (X-Frame-Options,nosniff,Referrer-Policy,Permissions-Policy, prod HSTS), and an admin-credential warning in.env.example.Accepted follow-ups (robustness/UX, not escalations): RP-initiated FerrisKey logout (local sign-out only today); a transaction/row-lock around the link-or-create upsert (the unique constraint already fails closed on the rare race); a full nonce-based CSP.
Test plan
Verified locally:
just cigreen — lint, format-check, typecheck, coverage (93%+, ≥80% gate), build, auditjust test-integration)0006applies cleanly;just seedworks with the new columnnext buildsucceeds;/api/auth/[...nextauth]route registeredNot yet exercised (needs the running FerrisKey Docker stack + a browser):
just ferriskey-up && just ferriskey-bootstrap→ pasteFERRISKEY_CLIENT_SECRETinto.env→just dev→ sign in → land on/authedwith seeded workspace/role/auth/sign-in?e=no_workspace:5555, admin/admin, is the source of truth if their API drifts)🤖 Generated with Claude Code